21.5 插件测试与调试

20 分钟阅读

21.5.1 单元测试#

测试框架配置#

// jest.config.ts export default { preset: 'ts-jest', testEnvironment: 'node', roots: ['<rootDir>/src'], testMatch: ['/tests//.ts', '**/?(.)+(spec|test).ts'], collectCoverageFrom: [ 'src//*.ts', '!src//.d.ts', '!src/**/.test.ts', '!src/**/*.spec.ts' ], coverageThreshold: { global: { branches: 80, functions: 80,

lines: 80, statements: 80 } }, moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' } };

基本单元测试#

bash
typescript

// __tests__/plugin.test.ts
import { MyPlugin } from '../src/plugin';

describe('MyPlugin', () => {
  let plugin: MyPlugin;

  beforeEach(() => {
    plugin = new MyPlugin();
  });

  afterEach(async () => {
    try {
      await plugin.cleanup();
    } catch (error) {
      // 忽略清理错误
    }
  });

  describe('initialization', () => {
    test('should initialize with correct configuration', async () => {
      await plugin.initialize({
        name: 'test-plugin',
        version: '1.0.0',
        description: 'Test plugin'
      });

      const info = plugin.getInfo();
      expect(info.name).toBe('test-plugin');
      expect(info.version).toBe('1.0.0');
    });

    test('should throw error if already initialized', async () => {
      await plugin.initialize({});

      await expect(plugin.initialize({})).rejects.toThrow();
    });
  });

  describe('lifecycle', () => {
    test('should start after initialization', async () => {
      await plugin.initialize({});
      await plugin.start();

      const status = plugin.getStatus();
      expect(status.enabled).toBe(true);
    });

    test('should stop after starting', async () => {
      await plugin.initialize({});
      await plugin.start();
      await plugin.stop();

      const status = plugin.getStatus();
      expect(status.enabled).toBe(false);
    });
  });
});

### 工具测试

// __tests__/tools/greeting.test.ts
import { GreetingTool } from '../../src/tools/greeting';
describe('GreetingTool', () => {
let tool: GreetingTool;
beforeEach(() => {
tool = new GreetingTool();
});
describe('execute', () => {
test('should generate English greeting', async () => {
const result = await tool.execute(
{ name: 'World', language: 'english' },
{}
);
expect(result.success).toBe(true);
expect(result.data.greeting).toBe('Hello, World!');
expect(result.data.language).toBe('english');
});
test('should generate Chinese greeting', async () => {
const result = await tool.execute(
{ name: 'World', language: 'chinese' },
{}
);
expect(result.success).toBe(true);
expect(result.data.greeting).toBe('你好,World!');
});
test('should handle invalid language gracefully', async () => {
const result = await tool.execute(
{ name: 'World', language: 'invalid' },
{}
);
expect(result.success).toBe(true);
expect(result.data.greeting).toBe('Hello, World!'); // 默认英语
});
});
describe('validate', () => {
test('should validate required parameters', () => {
const result = tool.validate({});
expect(result.valid).toBe(false);
expect(result.errors).toContain('Missing required parameter: name');
});
test('should validate parameter types', () => {
const result = tool.validate({ name: 123 });
expect(result.valid).toBe(false);
expect(result.errors).toContain('Parameter name must be a string');
});
test('should pass valid parameters', () => {
const result = tool.validate({ name: 'World' });
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
});

命令测试#

bash
typescript

// __tests__/commands/greet.test.ts
import { GreetCommand } from '../../src/commands/greet';

describe('GreetCommand', () => {
  let command: GreetCommand;

  beforeEach(() => {
    command = new GreetCommand();
  });

  describe('execute', () => {
    test('should greet informally by default', async () => {
      const result = await command.execute(
        ['--name', 'World'],
        {}
      );

      expect(result.success).toBe(true);
      expect(result.output).toContain('Hey, World!');
    });

    test('should greet formally with flag', async () => {
      const result = await command.execute(
        ['--name', 'World', '--formal'],
        {}
      );

      expect(result.success).toBe(true);
      expect(result.output).toContain('Good day, World.');
    });

    test('should handle missing name parameter', async () => {
      const result = await command.execute([], {});

      expect(result.success).toBe(false);
      expect(result.error).toBeDefined();
    });
  });

  describe('parseArgs', () => {
    test('should parse long arguments', () => {
      const parsed = command.parseArgs(['--name', 'World', '--formal']);

      expect(parsed.name).toBe('World');
      expect(parsed.formal).toBe(true);
    });

    test('should parse short arguments', () => {
      const parsed = command.parseArgs(['-n', 'World', '-f']);

      expect(parsed.name).toBe('World');
      expect(parsed.formal).toBe(true);
    });

    test('should use default values', () => {
      const parsed = command.parseArgs(['--name', 'World']);

      expect(parsed.name).toBe('World');
      expect(parsed.formal).toBe(false);
    });
  });

  describe('help', () => {
    test('should generate help text', () => {
      const help = command.help();

      expect(help).toContain('Command: greet');
      expect(help).toContain('Description:');
      expect(help).toContain('Usage:');
      expect(help).toContain('Options:');
    });
  });
});

### 钩子测试

// __tests__/hooks/logging.test.ts
import { LoggingHook } from '../../src/hooks/logging';
describe('LoggingHook', () => {
let hook: LoggingHook;
let consoleLogSpy: jest.SpyInstance;
beforeEach(() => {
hook = new LoggingHook();
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
describe('execute', () => {
test('should log command execution', async () => {
const event = {
type: 'before_command',
data: {
command: 'greet',
args: ['--name', 'World']
},
timestamp: new Date()
};
const result = await hook.execute(event, {});
expect(result.success).toBe(true);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('Executing command: greet')
);
});
test('should not prevent default behavior', async () => {
const event = {
type: 'before_command',
data: {},
timestamp: new Date()
};
const result = await hook.execute(event, {});
expect(result.success).toBe(true);
expect(result.preventDefault).toBeUndefined();
});
});
});

21.5.2 集成测试#

插件集成测试#

bash
typescript

// __tests__/integration/plugin.integration.test.ts
import { MyPlugin } from '../../src/plugin';

describe('MyPlugin Integration', () => {
  let plugin: MyPlugin;

  beforeEach(async () => {
    plugin = new MyPlugin();
    await plugin.initialize({});
  });

  afterEach(async () => {
    try {
      await plugin.stop();
      await plugin.cleanup();
    } catch (error) {
      // 忽略清理错误
    }
  });

  describe('full lifecycle', () => {
    test('should complete full lifecycle', async () => {
      // 启动插件
      await plugin.start();

      // 验证插件运行中
      let status = plugin.getStatus();
      expect(status.enabled).toBe(true);

      // 停止插件
      await plugin.stop();

      // 验证插件已停止
      status = plugin.getStatus();
      expect(status.enabled).toBe(false);

      // 清理插件
      await plugin.cleanup();
    });
  });

  describe('tool integration', () => {
    test('should execute tool through plugin', async () => {
      await plugin.start();

      const result = await plugin.toolManager.execute(
        'greeting',
        { name: 'World' },
        {}
      );

      expect(result.success).toBe(true);
      expect(result.data.greeting).toBeDefined();
    });
  });

  describe('command integration', () => {
    test('should execute command through plugin', async () => {
      await plugin.start();

      const result = await plugin.commandManager.execute(
        'greet',
        ['--name', 'World'],
        {}
      );

      expect(result.success).toBe(true);
      expect(result.output).toBeDefined();
    });
  });

  describe('hook integration', () => {
    test('should execute hooks through plugin', async () => {
      await plugin.start();

      const event = {
        type: 'before_command',
        data: {
          command: 'greet',
          args: ['--name', 'World']
        },
        timestamp: new Date()
      };

      const result = await plugin.hookManager.execute(
        'before_command',
        event,
        {}
      );

      expect(result.success).toBe(true);
    });
  });
});

### 端到端测试

// __tests__/e2e/plugin.e2e.test.ts
import { MyPlugin } from '../../src/plugin';
describe('MyPlugin E2E', () => {
let plugin: MyPlugin;
beforeEach(async () => {
plugin = new MyPlugin();
await plugin.initialize({});
await plugin.start();
});
afterEach(async () => {
try {
await plugin.stop();
await plugin.cleanup();
} catch (error) {
// 忽略清理错误
}
});
test('should handle complete workflow', async () => {
// 1. 执行工具
const toolResult = await plugin.toolManager.execute(
'greeting',
{ name: 'World' },
{}
);
expect(toolResult.success).toBe(true);
// 2. 执行命令
const commandResult = await plugin.commandManager.execute(
'greet',
['--name', 'World'],
{}
);
expect(commandResult.success).toBe(true);
// 3. 验证插件状态
const status = plugin.getStatus();
expect(status.enabled).toBe(true);
});
test('should handle errors gracefully', async () => {
// 执行无效工具
const result = await plugin.toolManager.execute(
'invalid-tool',
{},
{}
);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
// 验证插件仍然运行
const status = plugin.getStatus();
expect(status.enabled).toBe(true);
});
});

21.5.3 测试工具和辅助函数#

Mock 工具#

bash
typescript

// __tests__/utils/mocks.ts
import { PluginContext } from '@claude-code/plugin-sdk';

/**
 * 创建 Mock 插件上下文
 */
export function createMockContext(): PluginContext {
  return {
    getService: jest.fn(),
    setService: jest.fn(),
    getData: jest.fn(),
    setData: jest.fn(),
    removeData: jest.fn(),
    clearData: jest.fn()
  };
}

/**
 * 创建 Mock 工具管理器
 */
export function createMockToolManager() {
  return {
    register: jest.fn(),
    unregister: jest.fn(),
    getTool: jest.fn(),
    getAllTools: jest.fn(),
    execute: jest.fn()
  };
}

/**
 * 创建 Mock 命令管理器
 */
export function createMockCommandManager() {
  return {
    register: jest.fn(),
    unregister: jest.fn(),
    getCommand: jest.fn(),
    getAllCommands: jest.fn(),
    execute: jest.fn()
  };
}

/**
 * 创建 Mock 钩子管理器
 */
export function createMockHookManager() {
  return {
    register: jest.fn(),
    unregister: jest.fn(),
    getHooks: jest.fn(),
    getAllHooks: jest.fn(),
    execute: jest.fn()
  };
}

### 测试辅助函数

// __tests__/utils/helpers.ts
import { MyPlugin } from '../../src/plugin';
/**
* 创建测试插件实例
*/
export async function createTestPlugin(): Promise<MyPlugin> {
const plugin = new MyPlugin();
await plugin.initialize({});
return plugin;
}
/**
* 创建并启动测试插件
*/
export async function createAndStartTestPlugin(): Promise<MyPlugin> {
const plugin = await createTestPlugin();
await plugin.start();
return plugin;
}
/**
* 清理测试插件
*/
export async function cleanupTestPlugin(plugin: MyPlugin): Promise<void> {
try {
await plugin.stop();
await plugin.cleanup();
} catch (error) {
// 忽略清理错误
}
}
/**
* 等待异步操作完成
*/
export function waitFor(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 重试函数
*/
export async function retry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
delay: number = 100
): Promise<T> {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (i < maxRetries - 1) {
await waitFor(delay);
}
}
}
throw lastError;
}

测试断言#

bash
typescript

// __tests__/utils/assertions.ts
import { ToolResult, CommandResult } from '@claude-code/plugin-sdk';

/**
 * 断言工具结果成功
 */
export function expectToolSuccess(result: ToolResult) {
  expect(result.success).toBe(true);
  expect(result.error).toBeUndefined();
}

/**
 * 断言工具结果失败
 */
export function expectToolFailure(result: ToolResult) {
  expect(result.success).toBe(false);
  expect(result.error).toBeDefined();
}

/**
 * 断言命令结果成功
 */
export function expectCommandSuccess(result: CommandResult) {
  expect(result.success).toBe(true);
  expect(result.error).toBeUndefined();
  expect(result.exitCode).toBe(0);
}

/**
 * 断言命令结果失败
 */
export function expectCommandFailure(result: CommandResult) {
  expect(result.success).toBe(false);
  expect(result.error).toBeDefined();
  expect(result.exitCode).not.toBe(0);
}

/**
 * 断言结果包含数据
 */
export function expectResultData(result: ToolResult | CommandResult) {
  expect(result.data).toBeDefined();
  expect(Object.keys(result.data).length).toBeGreaterThan(0);
}

## 21.5.4 调试技巧

### 使用 VS Code 调试

// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Plugin",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Debug Tests",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "test:watch"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
},
{
"type": "node",
"request": "launch",
"name": "Debug Current Test",
"runtimeExecutable": "npm",
"runtimeArgs": ["test", "--", "${fileBasenameNoExtension}"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}

日志调试#

bash
typescript

// src/utils/logger.ts
export class DebugLogger {
  private enabled: boolean;

  constructor(enabled: boolean = process.env.DEBUG === 'true') {
    this.enabled = enabled;
  }

  log(message: string, data?: any): void {
    if (!this.enabled) {
      return;
    }

    console.log(`[DEBUG] ${message}`, data || '');
  }

  error(message: string, error?: Error): void {
    console.error(`[ERROR] ${message}`, error || '');
  }

  trace(message: string, data?: any): void {
    if (!this.enabled) {
      return;
    }

    console.trace(`[TRACE] ${message}`, data || '');
  }
}

// 使用示例
const logger = new DebugLogger();

export class MyPlugin extends Plugin {
  async initialize(config: PluginConfig): Promise<void> {
    logger.log('Initializing plugin', { config });

    try {
      // 初始化逻辑
      logger.log('Plugin initialized successfully');
    } catch (error) {
      logger.error('Failed to initialize plugin', error);
      throw error;
    }
  }
}

### 性能分析

// src/utils/profiler.ts
export class Profiler {
private measurements: Map<string, number[]> = new Map();
/**
* 测量函数执行时间
*/
async measure<T>(name: string, fn: () => Promise<T>): Promise<T> {
const start = Date.now();
try {
return await fn();
} finally {
const duration = Date.now() - start;
if (!this.measurements.has(name)) {
this.measurements.set(name, []);
}
this.measurements.get(name)!.push(duration);
}
}
/**
* 获取测量结果
*/
getStats(name: string) {
const measurements = this.measurements.get(name);
if (!measurements || measurements.length === 0) {
return null;
}
const sum = measurements.reduce((a, b) => a + b, 0);
const avg = sum / measurements.length;
const min = Math.min(...measurements);
const max = Math.max(...measurements);
return {
count: measurements.length,
sum,
avg,
min,
max
};
}
/**
* 打印所有统计信息
*/
printStats(): void {
for (const [name, measurements] of this.measurements.entries()) {
const stats = this.getStats(name);
console.log(`[Profiler] ${name}:`, stats);
}
}
}
// 使用示例
const profiler = new Profiler();
export class MyPlugin extends Plugin {
async executeTool(name: string, params: any): Promise<ToolResult> {
return profiler.measure(`tool.${name}`, async () => {
return this.toolManager.execute(name, params, {});
});
}
}

错误追踪#

bash
typescript

// src/utils/error-tracker.ts
export class ErrorTracker {
  private errors: Error[] = [];

  /**
   * 追踪错误
   */
  track(error: Error): void {
    this.errors.push(error);
    console.error('[ErrorTracker]', error);
  }

  /**
   * 获取所有错误
   */
  getErrors(): Error[] {
    return [...this.errors];
  }

  /**
   * 清除错误
   */
  clear(): void {
    this.errors = [];
  }

  /**
   * 获取错误统计
   */
  getStats() {
    const errorTypes = new Map<string, number>();

    for (const error of this.errors) {
      const type = error.constructor.name;
      errorTypes.set(type, (errorTypes.get(type) || 0) + 1);
    }

    return {
      total: this.errors.length,
      types: Object.fromEntries(errorTypes)
    };
  }
}

// 使用示例
const errorTracker = new ErrorTracker();

export class MyPlugin extends Plugin {
  async executeTool(name: string, params: any): Promise<ToolResult> {
    try {
      return await this.toolManager.execute(name, params, {});
    } catch (error) {
      errorTracker.track(error);
      throw error;
    }
  }
}

## 21.5.5 测试最佳实践

### 1. 测试命名

// 好的测试命名
describe('GreetingTool', () => {
describe('execute', () => {
test('should generate English greeting when language is English', async () => {
// 测试代码
});
test('should generate Chinese greeting when language is Chinese', async () => {
// 测试代码
});
});
});
// 不好的测试命名
describe('GreetingTool', () => {
test('test1', async () => {
// 测试代码
});
test('test2', async () => {
// 测试代码
});
});

2. 测试隔离#

bash
typescript

// 每个测试都应该独立运行
describe('MyPlugin', () => {
  let plugin: MyPlugin;

  beforeEach(() => {
    // 每个测试前创建新实例
    plugin = new MyPlugin();
  });

  afterEach(async () => {
    // 每个测试后清理
    await plugin.cleanup();
  });

  test('test 1', async () => {
    // 不依赖其他测试
  });

  test('test 2', async () => {
    // 不依赖其他测试
  });
});

### 3. 测试覆盖率

// 确保测试覆盖所有代码路径
describe('GreetingTool', () => {
describe('execute', () => {
test('should handle English language', async () => {
// 覆盖 English 分支
});
test('should handle Chinese language', async () => {
// 覆盖 Chinese 分支
});
test('should handle Spanish language', async () => {
// 覆盖 Spanish 分支
});
test('should handle unknown language', async () => {
// 覆盖默认分支
});
});
});

4. 测试速度#

bash
typescript

// 使用 Mock 加速测试
describe('MyPlugin', () => {
  test('should execute tool quickly', async () => {
    // Mock 工具管理器
    const mockToolManager = createMockToolManager();
    mockToolManager.execute.mockResolvedValue({
      success: true,
      data: { result: 'mocked' }
    });

    plugin.toolManager = mockToolManager;

    // 快速执行测试
    const result = await plugin.executeTool('test', {});
    expect(result.success).toBe(true);
  });
});

### 5. 测试可维护性

// 使用辅助函数提高可维护性
describe('MyPlugin', () => {
test('should handle multiple tool executions', async () => {
const tools = ['tool1', 'tool2', 'tool3'];
for (const tool of tools) {
const result = await plugin.executeTool(tool, {});
expectToolSuccess(result);
}
});
});

21.5.6 运行测试#

运行所有测试#

bash
bash

# 运行所有测试
npm test

# 运行测试并生成覆盖率报告
npm run test -- --coverage

# 监听模式
npm run test:watch

### 运行特定测试

# 运行特定测试文件
npm test -- plugin.test.ts
# 运行特定测试套件
npm test -- --testNamePattern="MyPlugin"
# 运行特定测试
npm test -- --testNamePattern="should initialize successfully"

测试覆盖率#

bash
bash

# 生成覆盖率报告
npm run test -- --coverage

# 查看覆盖率报告
open coverage/lcov-report/index.html

# 设置覆盖率阈值
npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'

### CI/CD 集成

# .github/workflows/test.yml
name: Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.x, 16.x, 18.x]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Generate coverage
run: npm run test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
files: ./coverage/lcov.info

标记本节教程为已读

记录您的学习进度,方便后续查看。